앱의 네트워크 상태를 신뢰하면 안 되는 이유

앱의 네트워크 상태를 신뢰하면 안 되는 이유

한눈에 보기

Wi-Fi나 cellular 연결 표시는 기기에 사용할 수 있는 network path가 있다는 신호이지, 앱의 API 요청이 성공한다는 보장이 아니다. Captive portal, DNS·TLS 실패, VPN, 서버 장애, 인증 만료가 모두 “연결됨” 상태에서 발생한다. 연결 상태는 요청을 깨우고 UX를 보조하는 힌트로만 사용하고, 업무 성공의 진실은 실제 요청 결과로 판단한다. 쓰기는 멱등한 오프라인 큐에 남기고 오류 종류별 timeout·backoff·사용자 행동을 분리한다.

목차

Wi-Fi 아이콘과 API 성공은 다른 사실이다

카페 Wi-Fi에 연결하면 운영체제는 network interface가 있다고 알려 준다. 하지만 브라우저 인증 페이지를 통과하기 전에는 앱 서버로 요청할 수 없다. 지하철에서 cellular 표시가 남아 있어도 DNS가 지연되거나 packet loss가 심할 수 있다. 회사 VPN이 특정 domain을 차단할 수도 있다.

flowchart LR
    A[Network interface] --> B[IP route]
    B --> C[DNS resolution]
    C --> D[TCP or QUIC]
    D --> E[TLS]
    E --> F[Gateway]
    F --> G[Application server]
    G --> H[Auth and business operation]

앞 단계 하나가 가능하다고 뒤 단계가 성공하는 것은 아니다.

Android의 NET_CAPABILITY_INTERNET도 network가 인터넷 접근용으로 설정됐다는 의미이고, 실제 public internet 접근에 가까운 신호는 별도의 validated capability다. validated network조차 특정 앱 서버의 장애나 갑작스러운 연결 손실까지 보장하지 않는다.

Apple의 NWPathMonitor와 Flutter의 connectivity plugin도 현재 사용할 수 있는 path·interface 변화를 관찰하는 도구다. 특정 HTTPS API 호출 결과를 대신하지 않는다.

판단 기준

“요청을 시도할 가치가 있는가?”에는 connectivity signal을 사용할 수 있다. “업무가 서버에 반영됐는가?”에는 해당 요청의 응답과 멱등성 계약을 사용한다.

이 글의 API와 코드 예시는 실제 프로젝트 구현이 아닌 가상 기록 앱을 기준으로 작성했다.

Network를 여러 계층으로 나누어 보기

앱에서 흔히 isOnline 하나로 표현하는 상태에는 서로 다른 질문이 섞여 있다.

계층 질문 관찰 수단
Interface Wi-Fi·cellular path가 있는가 OS network monitor
Internet path 외부 인터넷 접근이 검증됐는가 OS capability
Endpoint reachability 대상 host의 DNS·TLS가 되는가 실제 connection
Service availability gateway와 API가 응답하는가 health 또는 업무 요청
Authentication 현재 session이 유효한가 API response
Business success 요청이 처리됐는가 업무 response·조회

하나의 boolean으로 합치면 인증 401도 offline으로 보이거나 서버 500도 “인터넷 없음” banner로 표시된다.

// 의미가 너무 많은 상태
bool isOnline = true;

다음처럼 서로 다른 상태를 유지한다.

enum NetworkPathHint {
  unavailable,
  available,
  expensive,
  constrained,
}

enum ServiceReachability {
  unknown,
  reachable,
  degraded,
  unreachable,
}

enum SessionState {
  authenticated,
  refreshing,
  expired,
}

NetworkPathHint.available이면서 ServiceReachability.unreachable일 수 있다. 이 조합이 captive portal이나 특정 endpoint 장애를 표현한다.

Online 여부로 요청을 막는 코드의 문제

다음 구현은 offline이면 예외를 빨리 내서 합리적으로 보인다.

Future<Entry> createEntry(CreateEntry input) async {
  if (!connectivityState.isOnline) {
    throw const OfflineException();
  }
  return api.createEntry(input);
}

하지만 상태 event와 실제 요청 사이에는 시간이 흐른다.

sequenceDiagram
    participant Monitor
    participant App
    participant API

    Monitor-->>App: online
    Note over App: 사용자가 저장 버튼 누름
    Note over Monitor,API: network path 단절
    App->>API: request
    API--xApp: connection failure

반대 race도 있다. monitor는 아직 offline인데 새 path가 이미 요청에 사용 가능할 수 있다. 위 코드는 성공할 수 있는 요청을 앱이 먼저 차단한다.

요청을 시도하고 결과를 처리하는 구조가 기본이다.

Future<Entry> createEntry(CreateEntry input) async {
  try {
    return await api
        .createEntry(input)
        .timeout(const Duration(seconds: 8));
  } on TransportFailure catch (error) {
    await offlineQueue.enqueue(input);
    throw CreateEntryFailure.queued(error.category);
  }
}

연결 신호가 명확히 unavailable이면 즉시 queue에 넣어 불필요한 timeout을 피할 수 있다. 그러나 queue 여부는 network boolean 하나가 아니라 작업의 offline 정책으로 결정한다.

연결 신호와 요청 결과를 다른 상태로 관리하기

Flutter의 connectivity observer를 infrastructure hint로 감싼다.

final class ConnectivityHintSource {
  ConnectivityHintSource(this.connectivity);

  final Connectivity connectivity;

  Stream<NetworkPathHint> observe() {
    return connectivity.onConnectivityChanged.map((results) {
      if (results.contains(ConnectivityResult.none)) {
        return NetworkPathHint.unavailable;
      }
      if (results.contains(ConnectivityResult.mobile)) {
        return NetworkPathHint.expensive;
      }
      return NetworkPathHint.available;
    });
  }
}

현재 package API는 버전마다 반환 형태가 달라질 수 있으므로 발행 시 공식 패키지 문서를 다시 확인한다. 이 stream을 domain success 판단에 직접 사용하지 않는다.

실제 API client는 transport outcome을 별도로 보고한다.

final class ReachabilityTracker {
  ServiceReachability _state = ServiceReachability.unknown;

  void record(ApiOutcome outcome) {
    _state = switch (outcome) {
      ApiSuccess() => ServiceReachability.reachable,
      ServerError() => ServiceReachability.degraded,
      DnsFailure() ||
      ConnectFailure() ||
      TlsFailure() ||
      RequestTimeout() => ServiceReachability.unreachable,
      AuthenticationFailure() => _state,
      ValidationFailure() => _state,
    };
  }
}

401과 422는 endpoint에 도달한 결과다. 이를 network unreachable로 바꾸지 않는다.

실제 요청 오류를 원인별로 분류하기

모든 exception을 네트워크 오류로 보여 주면 복구 행동을 고를 수 없다.

오류 의미 기본 행동
DNS failure host 해석 실패 제한된 retry
connect timeout 연결 성립 실패 backoff 또는 queue
TLS failure 인증서·handshake 문제 자동 무한 retry 금지
read timeout 응답 지연 operation 결과 불명 가능
connection reset 전송 중 단절 멱등성 확인 후 retry
401 session 만료 refresh 한 번
403 권한 없음 사용자 안내
429 rate limit Retry-After 존중
5xx server 장애 backoff, cached read
4xx validation 요청 수정 필요 retry 금지

쓰기 요청의 read timeout은 server가 처리했을 수 있다. operation ID 없이 바로 재전송하지 않는다. 오프라인 큐에 멱등성이 필요한 이유에서 이 불확실성을 자세히 다뤘다.

SyncDecision decide(ApiFailure failure) {
  return switch (failure) {
    NoNetworkPath() => const SyncDecision.queue(),
    DnsFailure() => const SyncDecision.retry(),
    ConnectTimeout() => const SyncDecision.retry(),
    ReadTimeout(:final operationId) =>
      SyncDecision.verifyOrRetry(operationId),
    Unauthorized() => const SyncDecision.refreshOnce(),
    RateLimited(:final retryAfter) =>
      SyncDecision.retryAfter(retryAfter),
    ValidationFailure() =>
      const SyncDecision.requiresUserAction(),
    TlsFailure() => const SyncDecision.stopAndReport(),
  };
}

TLS 오류를 offline처럼 계속 retry하면 인증서 설정 오류나 interception을 숨길 수 있다.

Ping 성공을 업무 API 성공으로 오해하지 않기

앱 시작 때 /ping을 호출하고 성공하면 online으로 표시하는 패턴도 제한이 있다.

final ok = await api.ping();
if (ok) {
  await uploadPendingEntries();
}

ping과 실제 API는 경로가 다를 수 있다.

health check는 server 상태 진단에는 유용하지만 각 업무 요청의 선행 허가증이 아니다. 매 요청 전에 ping하면 latency와 traffic만 두 배가 된다.

실제 queue 항목을 멱등하게 시도하고 그 결과로 다음 작업을 조절한다.

첫 pending operation 성공 → 제한된 batch 계속
transport 실패 → worker 중단, next attempt 예약
429 → server hint까지 중단
영구 4xx → 해당 row 격리, 다른 partition 진행

Connectivity Event는 큐를 깨우는 신호로 사용하기

offline에서 available event가 오면 즉시 모든 요청을 동시에 시작하지 않는다. worker에 “일할 가능성이 생겼다”고 알려 한 번 깨운다.

connectivityHints.listen((hint) {
  if (hint != NetworkPathHint.unavailable) {
    syncScheduler.requestRun(
      reason: SyncWakeReason.networkChanged,
    );
  }
});

scheduler는 중복 wake-up을 합치고 현재 worker가 실행 중이면 새 worker를 만들지 않는다.

Future<void> requestRun({
  required SyncWakeReason reason,
}) async {
  if (_runInFlight != null) {
    _pendingWakeUp = true;
    return _runInFlight;
  }

  _runInFlight = _runWorker(reason);
  try {
    await _runInFlight;
  } finally {
    _runInFlight = null;
    if (_pendingWakeUp) {
      _pendingWakeUp = false;
      unawaited(requestRun(reason: SyncWakeReason.coalesced));
    }
  }
}

연결 event가 여러 번 흔들리는 환경에서도 queue storm을 만들지 않는다. connectivity_plus 공식 설명도 일부 플랫폼에서 중복되거나 빠르게 바뀌는 결과가 있을 수 있음을 경고한다.

읽기와 쓰기의 Offline 전략을 다르게 두기

읽기는 마지막 성공 cache를 보여 주고 background refresh를 시도할 수 있다.

Future<EntryListState> loadEntries() async {
  final cached = await localStore.readEntries();

  unawaited(
    api.fetchEntries().then(localStore.replaceEntries),
  );

  return cached.isEmpty
      ? const EntryListState.loading()
      : EntryListState.cached(cached);
}

실제 구현에서는 background Future 오류를 삼키지 않고 tracker에 기록한다.

쓰기는 사용자 의도를 잃지 않게 local mutation과 offline operation을 transaction으로 저장한다.

Read failure  → last-known-good + stale indicator
Write failure → pending local state + durable operation
Delete failure → tombstone + durable delete operation

모든 화면에 “인터넷이 없습니다” modal을 띄우기보다 기능별 degradation을 설계한다. cache로 볼 수 있는 화면과 server 확인이 필수인 결제 화면의 정책은 다르다.

Timeout과 Retry는 작업 성격에 맞추기

하나의 전역 3초 timeout을 모든 API에 적용하면 작은 metadata 조회와 큰 upload를 같은 조건으로 다룬다.

final policy = switch (operation.kind) {
  OperationKind.metadataRead => const TimeoutPolicy(
      connect: Duration(seconds: 3),
      total: Duration(seconds: 5),
    ),
  OperationKind.imageUpload => const TimeoutPolicy(
      connect: Duration(seconds: 5),
      total: Duration(seconds: 60),
    ),
};

connect timeout과 response timeout을 구분할 수 있는 client라면 각각 관측한다. retry는 다음 조건을 본다.

지수 backoff와 jitter로 여러 client가 동시에 server를 다시 두드리는 일을 줄인다.

Expensive와 Constrained Network 고려하기

연결 가능 여부 외에도 사용자의 데이터 비용과 Low Data Mode 같은 제약이 있다. Apple의 NWPath는 expensive·constrained 특성을 제공하고 Android도 metered capability를 구분한다.

이 신호로 중요한 작은 동기화를 막지는 않되 큰 작업의 정책을 조절할 수 있다.

작업 일반 network expensive·constrained
텍스트 operation 즉시 즉시 또는 batch
썸네일 download prefetch 화면에 필요할 때
원본 사진 backup background Wi-Fi 대기 옵션
analytics batch 주기적 더 길게 합침
보안 revoke 즉시 즉시

“Wi-Fi에서만” 설정이 있어도 Wi-Fi가 무제한이라는 보장은 없다. OS의 cost 신호와 사용자 preference를 함께 사용한다.

앱 Lifecycle 이후 상태를 다시 확인하기

앱이 background에 있는 동안 path event를 받지 못하거나 OS가 전달을 제한할 수 있다. resume 시 현재 connectivity hint를 다시 읽고, 더 중요한 것은 만료된 작업과 session을 재평가하는 것이다.

Future<void> onAppResumed() async {
  final hint = await connectivityHintSource.current();
  networkState.updateHint(hint);

  await offlineQueue.recoverExpiredLeases();
  syncScheduler.requestRun(
    reason: SyncWakeReason.appResumed,
  );
}

resume 직후 available이라는 이유로 로그인 상태까지 유효하다고 보지 않는다. 실제 첫 API의 401을 auth repository가 처리한다.

listener는 화면마다 중복 등록하지 않고 앱 수명의 service가 소유하며 dispose에서 해제한다.

사용자에게 의미 있는 상태 보여 주기

전역 online banner 하나보다 사용자가 수행한 작업 상태를 보여 준다.

저장됨                 server 동기화 완료
이 기기에 저장됨       queue pending
동기화 중              sending
다시 시도 예정         retry waiting
확인이 필요함          permanent failure

연결이 돌아오면 자동으로 처리할 작업에 “재시도” 버튼만 강요하지 않는다. 반대로 validation 오류처럼 자동 해결되지 않는 실패를 “오프라인”으로 숨기지 않는다.

stale read에는 마지막 업데이트 시각을 표시한다.

Text(
  state.isStale
      ? '${formatRelative(state.updatedAt)} 기준'
      : '최신 정보',
)

UI가 network interface 이름을 업무 상태로 번역하지 않고, cache freshness와 operation state를 표현하게 한다.

테스트할 네트워크 실패 행렬

조건 기대 동작
interface 없음 쓰기 즉시 queue
Wi-Fi + captive portal 실제 API 실패, online 성공 오인 금지
DNS failure 제한된 retry와 진단
TLS failure 무한 retry 금지
connect 후 packet loss timeout과 operation 결과 불명 처리
API 500 cache 유지·backoff
API 401 refresh 한 번, offline banner 금지
API 422 사용자 수정 상태
network event 빠른 반복 worker 하나만 실행
app background 중 path 변경 resume에서 재평가
expensive network 큰 prefetch 지연
queue 전송 중 network 전환 lease·멱등성으로 재시도

실제 기기에서는 airplane mode 토글만 시험하지 않는다. Network Link Conditioner나 proxy를 이용해 latency, bandwidth, packet loss, DNS·TLS 오류를 각각 재현한다. server mock은 response delay와 응답 직전 연결 종료를 지원하게 한다.

운영 지표로 Client와 Server 문제 구분하기

network_path_hint{state=available}
api_transport_failures_total{category=dns}
api_requests_total{status=500}
api_latency_ms{endpoint_group=entries}
offline_queue_oldest_age_seconds
sync_wakeup_total{reason=network_changed}
sync_run_total{result=coalesced}

OS path available 비율이 정상인데 특정 endpoint 5xx가 증가하면 client network 문제가 아니다. 여러 API가 DNS failure로 동시에 오르면 resolver나 network 환경을 의심할 수 있다.

endpoint 전체 path나 query, token은 telemetry에 넣지 않는다. route template과 오류 category를 사용한다.

구현 체크리스트

상태 모델

요청과 재시도

Lifecycle과 비용

검증

마무리

운영체제가 알려 주는 network 상태는 가치가 있다. 사용할 수 있는 interface, expensive·constrained path, 변화 시점을 알려 주어 UX와 scheduler를 개선한다. 문제는 그 신호에 “우리 API가 성공한다”는 의미까지 부여할 때 생긴다.

업무 요청의 성공 여부는 실제 응답으로 판단한다. DNS, TLS, timeout, 인증, server, validation 실패를 분리하고 각 오류에 맞는 재시도와 사용자 행동을 선택한다. 읽기는 마지막 성공 cache로 degrade하고, 쓰기는 멱등한 durable queue에 남긴다.

Connectivity event는 요청을 허가하거나 거부하는 gate가 아니라 중단된 동기화를 다시 시도할 수 있다는 힌트다. 이 경계를 지키면 network가 연결과 단절 사이에서 흔들려도 앱의 데이터 상태는 그 흔들림을 그대로 따라가지 않는다.

관련 노트

참고 자료